package com.thghh.resource;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;

import com.thghh.resource.util.ResourceLoaderUtils;
import com.thghh.resource.util.SecurityUtilities;
import com.thghh.resource.util.StringUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A {@link ResourceLoader} that uses files inside a specified directory as the source of Resources. By default it does
 * security checks on the <em>canonical</em> path that will prevent it serving Resources outside that specified
 * directory. If you want symbolic links that point outside the Resource directory to work, you need to disable this
 * feature by using {@link #FileResourceLoader(File, boolean)} with {@code true} second argument, but before that, check
 * the security implications there!
 */
public class FileResourceLoader implements ResourceLoader {
    
    /**
     * By setting this Java system property to {@code true}, you can change the default of
     * {@code #getEmulateCaseSensitiveFileSystem()}.
     */
    public static String SYSTEM_PROPERTY_NAME_EMULATE_CASE_SENSITIVE_FILE_SYSTEM
            = "org.freemarker.emulateCaseSensitiveFileSystem";
    private static final boolean EMULATE_CASE_SENSITIVE_FILE_SYSTEM_DEFAULT;
    static {
        final String s = SecurityUtilities.getSystemProperty(SYSTEM_PROPERTY_NAME_EMULATE_CASE_SENSITIVE_FILE_SYSTEM,
                "false");
        boolean emuCaseSensFS;
        try {
            emuCaseSensFS = StringUtil.getYesNo(s);
        } catch (Exception e) {
            emuCaseSensFS = false;
        }
        EMULATE_CASE_SENSITIVE_FILE_SYSTEM_DEFAULT = emuCaseSensFS;
    }

    private static final int CASE_CHECH_CACHE_HARD_SIZE = 50;
    private static final int CASE_CHECK_CACHE__SOFT_SIZE = 1000;
    private static final boolean SEP_IS_SLASH = File.separatorChar == '/';
    
    private static final Logger LOG = LoggerFactory.getLogger(FileResourceLoader.class);
    
    public final File baseDir;
    private final String canonicalBasePath;
    private boolean emulateCaseSensitiveFileSystem;
    private MruCacheStorage correctCasePaths;

    /**
     * Creates a new file Resource resource that will use the current directory (the value of the system property
     * <code>user.dir</code> as the base directory for loading Resources. It will not allow access to Resource files
     * that are accessible through symlinks that point outside the base directory.
     * 
     * @deprecated Relying on what the current directory is is a bad practice; use
     *             {@link FileResourceLoader#FileResourceLoader(File)} instead.
     */
    @Deprecated
    public FileResourceLoader() throws IOException {
        this(new File(SecurityUtilities.getSystemProperty("user.dir")));
    }

    /**
     * Creates a new file Resource loader that will use the specified directory
     * as the base directory for loading Resources. It will not allow access to
     * Resource files that are accessible through symlinks that point outside 
     * the base directory.
     * @param baseDir the base directory for loading Resources
     */
    public FileResourceLoader(final File baseDir) throws IOException {
        this(baseDir, false);
    }

    /**
     * Creates a new file Resource loader that will use the specified directory as the base directory for loading
     * Resources. See the parameters for allowing symlinks that point outside the base directory.
     * 
     * @param baseDir
     *            the base directory for loading Resources
     * 
     * @param disableCanonicalPathCheck
     *            If {@code true}, it will not check if the file to be loaded is inside the {@code baseDir} or not,
     *            according the <em>canonical</em> paths of the {@code baseDir} and the file to load. Note that
     *            {@link Configuration#getResource(String)} and (its overloads) already prevents backing out from the
     *            Resource directory with paths like {@code /../../../etc/password}, however, that can be circumvented
     *            with symbolic links or other file system features. If you really want to use symbolic links that point
     *            outside the {@code baseDir}, set this parameter to {@code true}, but then be very careful with
     *            Resource paths that are supplied by the visitor or an external system.
     */
    public FileResourceLoader(final File baseDir, final boolean disableCanonicalPathCheck) throws IOException {
        try {
            Object[] retval = AccessController.doPrivileged(new PrivilegedExceptionAction<Object[]>() {
                @Override
                public Object[] run() throws IOException {
                    if (!baseDir.exists()) {
                        throw new FileNotFoundException(baseDir + " does not exist.");
                    }
                    if (!baseDir.isDirectory()) {
                        throw new IOException(baseDir + " is not a directory.");
                    }
                    Object[] retval = new Object[2];
                    if (disableCanonicalPathCheck) {
                        retval[0] = baseDir;
                        retval[1] = null;
                    } else {
                        retval[0] = baseDir.getCanonicalFile();
                        String basePath = ((File) retval[0]).getPath();
                        // Most canonical paths don't end with File.separator,
                        // but some does. Like, "C:\" VS "C:\Resources".
                        if (!basePath.endsWith(File.separator)) {
                            basePath += File.separatorChar;
                        }
                        retval[1] = basePath;
                    }
                    return retval;
                }
            });
            this.baseDir = (File) retval[0];
            this.canonicalBasePath = (String) retval[1];
            
            setEmulateCaseSensitiveFileSystem(getEmulateCaseSensitiveFileSystemDefault());
        } catch (PrivilegedActionException e) {
            throw (IOException) e.getException();
        }
    }
    
    @Override
    public Object findResource(final String name) throws IOException {
        try {
            return AccessController.doPrivileged(new PrivilegedExceptionAction<File>() {
                @Override
                public File run() throws IOException {
                    File source = new File(baseDir, SEP_IS_SLASH ? name : 
                        name.replace('/', File.separatorChar));
                    if (!source.isFile()) {
                        return null;
                    }
                    // Security check for inadvertently returning something 
                    // outside the Resource directory when linking is not 
                    // allowed.
                    if (canonicalBasePath != null) {
                        String normalized = source.getCanonicalPath();
                        if (!normalized.startsWith(canonicalBasePath)) {
                            throw new SecurityException(source.getAbsolutePath() 
                                    + " resolves to " + normalized + " which "
                                    + " doesn't start with " + canonicalBasePath);
                        }
                    }
                    
                    if (emulateCaseSensitiveFileSystem && !isNameCaseCorrect(source)) {
                        return null;
                    }
                    
                    return source;
                }
            });
        } catch (PrivilegedActionException e) {
            throw (IOException) e.getException();
        }
    }
    
    @Override
    public long getLastModified(final Object ResourceSource) {
        return (AccessController.doPrivileged(new PrivilegedAction<Long>() {
            @Override
            public Long run() {
                return Long.valueOf(((File) ResourceSource).lastModified());
            }
        })).longValue();
    }
    
    @Override
    public Reader getReader(final Object ResourceSource, final String encoding) throws IOException {
        try {
            return AccessController.doPrivileged(new PrivilegedExceptionAction<Reader>() {
                @Override
                public Reader run() throws IOException {
                    if (!(ResourceSource instanceof File)) {
                        throw new IllegalArgumentException(
                                "ResourceSource wasn't a File, but a: " + 
                                ResourceSource.getClass().getName());
                    }
                    return new InputStreamReader(new FileInputStream((File) ResourceSource), encoding);
                }
            });
        } catch (PrivilegedActionException e) {
            throw (IOException) e.getException();
        }
    }
    
    /**
     * Called by {@link #findResource(String)} when {@link #getEmulateCaseSensitiveFileSystem()} is {@code true}.
     */
    private boolean isNameCaseCorrect(File source) throws IOException {
        final String sourcePath = source.getPath();
        synchronized (correctCasePaths) {
            if (correctCasePaths.get(sourcePath) != null) {
                return true;
            }
        }
        
        final File parentDir = source.getParentFile();
        if (parentDir != null) {
            if (!baseDir.equals(parentDir) && !isNameCaseCorrect(parentDir)) {
                return false;
            }
            
            final String[] listing = parentDir.list();
            if (listing != null) {
                final String fileName = source.getName();
                
                boolean identicalNameFound = false;
                for (int i = 0; !identicalNameFound && i < listing.length; i++) {
                    if (fileName.equals(listing[i])) {
                        identicalNameFound = true;
                    }
                }
        
                if (!identicalNameFound) {
                    // If we find a similarly named file that only differs in case, then this is a file-not-found.
                    for (int i = 0; i < listing.length; i++) {
                        final String listingEntry = listing[i];
                        if (fileName.equalsIgnoreCase(listingEntry)) {
                            if (LOG.isDebugEnabled()) {
                                LOG.debug("Emulating file-not-found because of letter case differences to the "
                                        + "real file, for: " + sourcePath);
                            }
                            return false;
                        }
                    }
                }
            }
        }

        synchronized (correctCasePaths) {
            correctCasePaths.put(sourcePath, Boolean.TRUE);        
        }
        return true;
    }

    @Override
    public void closeResource(Object ResourceSource) {
        // Do nothing.
    }
    
    /**
     * Returns the base directory in which the Resources are searched. This comes from the constructor argument, but
     * it's possibly a canonicalized version of that. 
     *  
     * @since 2.3.21
     */
    public File getBaseDirectory() {
        return baseDir;
    }
    
    /**
     * Intended for development only, checks if the Resource name matches the case (upper VS lower case letters) of the
     * actual file name, and if it doesn't, it emulates a file-not-found even if the file system is case insensitive.
     * This is useful when developing application on Windows, which will be later installed on Linux, OS X, etc. This
     * check can be resource intensive, as to check the file name the directories involved, up to the
     * {@link #getBaseDirectory()} directory, must be listed. Positive results (matching case) will be cached without
     * expiration time.
     * 
     * <p>The default in {@link FileResourceLoader} is {@code false}, but subclasses may change they by overriding
     * {@link #getEmulateCaseSensitiveFileSystemDefault()}.
     * 
     * @since 2.3.23
     */
    public void setEmulateCaseSensitiveFileSystem(boolean nameCaseChecked) {
        // Ensure that the resource exists exactly when needed:
        if (nameCaseChecked) {
            if (correctCasePaths == null) {
                correctCasePaths = new MruCacheStorage(CASE_CHECH_CACHE_HARD_SIZE, CASE_CHECK_CACHE__SOFT_SIZE);
            }
        } else {
            correctCasePaths = null;
        }
        
        this.emulateCaseSensitiveFileSystem = nameCaseChecked;
    }

    /**
     * Getter pair of {@link #setEmulateCaseSensitiveFileSystem(boolean)}.
     * 
     * @since 2.3.23
     */
    public boolean getEmulateCaseSensitiveFileSystem() {
        return emulateCaseSensitiveFileSystem;
    }

    /**
     * Returns the default of {@link #getEmulateCaseSensitiveFileSystem()}. In {@link FileResourceLoader} it's
     * {@code false}, unless the {@link #SYSTEM_PROPERTY_NAME_EMULATE_CASE_SENSITIVE_FILE_SYSTEM} system property was
     * set to {@code true}, but this can be overridden here in custom subclasses. For example, if your environment
     * defines something like developer mode, you may want to override this to return {@code true} on Windows.
     * 
     * @since 2.3.23
     */
    protected boolean getEmulateCaseSensitiveFileSystemDefault() {
        return EMULATE_CASE_SENSITIVE_FILE_SYSTEM_DEFAULT;
    }

    /**
     * Show class name and some details that are useful in Resource-not-found errors.
     * 
     * @since 2.3.21
     */
    @Override
    public String toString() {
        // We don't StringUtil.jQuote paths here, because on Windows there will be \\-s then that some may find
        // confusing.
        return ResourceLoaderUtils.getClassNameForToString(this) + "("
                + "baseDir=\"" + baseDir + "\""
                + (canonicalBasePath != null ? ", canonicalBasePath=\"" + canonicalBasePath + "\"" : "")
                + (emulateCaseSensitiveFileSystem ? ", emulateCaseSensitiveFileSystem=true" : "")
                + ")";
    }
    
}
